Skip to content

feat: restrict stablecoin currency to ISO 4217 fiat allowlist#46

Merged
ilikesymmetry merged 14 commits into
mainfrom
feat/iso4217-currency-validation
May 21, 2026
Merged

feat: restrict stablecoin currency to ISO 4217 fiat allowlist#46
ilikesymmetry merged 14 commits into
mainfrom
feat/iso4217-currency-validation

Conversation

@ilikesymmetry
Copy link
Copy Markdown
Collaborator

@ilikesymmetry ilikesymmetry commented May 21, 2026

Summary

  • Adds src/utils/ISO4217.sol library exposing two primitives the TokenFactory mock uses to validate B20StablecoinCreateParams.currency:
    • isValidFiatCode(string) — allowlist of active ISO 4217 circulating-fiat alphabetic codes
    • excludedAt(uint256) / excludedCount() — enumerable blocklist of ISO 4217 entries deliberately rejected (precious metals, supranational synthetics, sentinels, funds codes), each with inline rationale
  • Replaces the prior empty-string-only check on currency with full allowlist enforcement. Rejections surface through a new InvalidCurrency(string code) error that carries the offending input verbatim for diagnostics.
  • Rewrites IB20Stablecoin and ITokenFactory.B20StablecoinCreateParams.currency natspec to document scope, trust model, regulatory alignment, and the friction this variant accepts by narrowing the term "stablecoin."
  • Consolidates the currency-validation test surface from 15 point tests to 5 — two explicit successes (majors + multi-country X-prefix), two fuzz reverts (universal non-allowlist rejection, blocklist enumeration), one atomicity test.

Scope: what's in, what's out

Included (any of these will round-trip through currency()):

  • ~150 active ISO 4217 alphabetic codes for sovereign / supranational national fiat currencies
  • All ten G10 currencies (USD, EUR, JPY, GBP, AUD, NZD, CAD, CHF, NOK, SEK) plus SGD
  • The four multi-country X-prefix circulating fiat codes: XOF, XAF, XCD, XPF (CFA Franc BCEAO, CFA Franc BEAC, East Caribbean Dollar, CFP Franc — real money used by tens of millions of people)

Explicitly excluded (enumerated in ISO4217.excludedAt with per-entry rationale, fuzz-tested by test_fuzz_createToken_revert_currency_blocklist):

  • Precious metals: XAU (gold), XAG (silver), XPT (platinum), XPD (palladium) — commodities, not means of payment
  • European composite units: XBA-XBD — defunct supranational accounting units
  • Other supranational synthetics: XDR (IMF SDRs), XSU (Sucre), XUA (ADB Unit of Account) — reserve assets, not circulating currencies
  • Sentinels: XXX (no-currency marker), XTS (test code)
  • Funds codes / indexing units: BOV, CHE, CHW, CLF, COU, MXV, USN, UYI, UYW — inflation-indexing devices and forex settlement conventions, not things one can hold or settle in
  • Implicitly excluded (by being off the active ISO 4217 list, caught by the universal-rejection fuzz): deprecated codes like CUC, HRK, VEF, ZWL; crypto tickers; arbitrary strings

Risks of the narrow scope, and mitigations

The headline risk is that "stablecoin" is used in industry and regulation more broadly than this variant accepts. FSB and BIS define stablecoins as any crypto-asset tracking any asset or basket. This variant scopes to single-fiat tracking specifically.

Concern Mitigation
Commodity-backed tokens marketed as stablecoins (PAXG, XAUT, AABBG) will not be admitted here These are structurally claims on a vault — securities-shaped instruments. They belong on the IB20Security variant, not IB20Stablecoin. Documented in IB20Stablecoin natspec.
Crypto-collateralized stablecoins (DAI, LUSD, crvUSD) might appear excluded They fit this variant fine — the backing mechanism is irrelevant to currency(); what matters is the peg target. If a token pegs to USD, declare "USD" regardless of whether the backing is custodial reserves, on-chain collateral, or T-bills. Documented in natspec.
Basket-pegged tokens (historically Libra/Diem) and algorithmic non-pegged stable assets (Ampleforth, historically Terra UST) have no current B-20 home Accepted trade-off. A future basket / ART variant or use of the B20 Default variant with custom monetary policy would be the path, not relaxation of this variant.
The variant name "Stablecoin" carries broader connotations than its admitted set Anchored in regulatory precedent — MiCA E-Money Tokens (EMTs), MAS Single-Currency Stablecoins (SCS), and US payment-stablecoin legislative proposals all draw the same line we do. The natspec spells out the alignment.
The allowlist is self-declared, not a trust signal Spelled out explicitly: the factory enforces format and membership only. Any protocol consuming currency() for authorization MUST layer its own issuer/contract allowlist on top.

Reviewer asks

  • Confirm the corrected allowlist scope matches intent (additions: XOF/XAF/XCD/XPF; removals: BOV/CHE/CHW/CLF/COU/CUC/MXV/USN/UYI/UYW vs. prior in-progress list)
  • Confirm the scope-and-naming framing in IB20Stablecoin natspec — particularly the "commodities go on Security" position — matches the broader variant-roadmap intent
  • Future Rust precompile impl will need to mirror the allowlist + blocklist contents from ISO4217.sol exactly; flag this for whoever picks up the precompile work

Test plan

  • forge build — clean
  • forge test — 350 tests pass (down from 359 pre-consolidation; net -9 from collapsing 15 currency tests into 5, plus 1 new multi-country X-prefix success test)
  • Universal-rejection fuzz exercises 256 random strings per run with vm.assume(!isValidFiatCode(input)), asserting revert + diagnostic round-trip
  • Blocklist fuzz drives seed % excludedCount() across all 22 documented blocklist entries
  • Atomicity test: failed create with invalid currency leaves no state at the deterministic address

🤖 Generated with Claude Code

Adds `ISO4217.sol` library exposing two primitives the `TokenFactory`
mock now uses to validate `B20StablecoinCreateParams.currency`: an
`isValidFiatCode` allowlist of circulating national fiat codes, and an
enumerable `excludedAt` / `excludedCount` blocklist of ISO 4217 entries
that are deliberately rejected (precious metals, supranational
synthetics, sentinels, funds codes) with per-entry rationale.

Replaces the prior empty-string-only check on `currency` with the
allowlist enforcement; rejections surface through a new
`InvalidCurrency(string)` error carrying the offending input. Updates
the stablecoin and factory natspec to describe the trust model
(self-declared identifier, not a trust signal — consumers bring their
own admission logic) and the scope (X-prefix is not categorical;
multi-country fiat codes XOF/XAF/XCD/XPF are included).

Consolidates the currency-validation test surface from 15 point tests
to 5: two explicit successes (majors + multi-country X-prefix), two
fuzz reverts (universal non-allowlist rejection, blocklist
enumeration), and one atomicity test.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@ilikesymmetry ilikesymmetry changed the base branch from master to main May 21, 2026 05:32
ilikesymmetry and others added 13 commits May 20, 2026 22:50
Documents why this variant is restricted to single-fiat tracking even
though "stablecoin" is used in industry and regulation to describe a
wider set of instruments. Anchors the narrowing to MiCA E-Money
Tokens, MAS Single-Currency Stablecoins, and US payment-stablecoin
legislative definitions — the regulatory regimes that have already
sub-divided the term along the same line we draw.

Calls out specifically that commodity-backed tokens marketed as
stablecoins (PAXG, XAUT) belong on IB20Security; that crypto-
collateralized fiat-pegged tokens (DAI, LUSD) fit this variant since
the peg target — not the collateral mechanism — is what currency()
expresses; and that basket-pegged and algorithmic non-pegged tokens
have no current B-20 home.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Moves the scope, exclusion-category reasoning, regulatory framing
(MiCA EMT / MAS SCS / FSB / BIS), and trust model out of the
IB20Stablecoin, ITokenFactory, and ISO4217 natspec into a single
canonical docs/iso4217-filter.md. The new file presents inclusion /
exclusion as a scannable table and totals ~26 lines.

Natspec now stays at the API-contract layer: what each function
does, what reverts, with one-line pointers to docs/ for the deeper
context. Per-entry rationale in ISO4217.excludedAt stays inline
since it answers "why isn't X on the list?" at the exact location
a reader would ask.

Net: -217 natspec lines, +26 docs lines. IB20Stablecoin.sol is now
20 lines total.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…and to full problem/solution/risks

Restructures the docs file into a four-section flow that's
self-describing on its own: problem statement (why constrain
currency at all), solution (ISO 4217 fiat subset), complete
specification (the inclusion/exclusion table), and risks +
mitigations (PAXG-class commodities go to Security; DAI-class
crypto-collateralized still fit; basket/algorithmic have no home;
naming friction with industry terminology is anchored in MiCA / MAS
precedent; trust model is consumer-layered).

Moves the file from docs/iso4217-filter.md to
docs/b20/stablecoin/currency-validation.md so the docs taxonomy
mirrors the interface hierarchy (B20 / B20Stablecoin / concept).

Updates all natspec links to the new path and trims two more
small natspec sprawls — the X-prefix inline comment and the
isValidFiatCode @notice — that duplicated content now in docs.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
MockTokenFactory's 7-line ISO-4217 explainer collapses to one line
pointing at docs/. The five currency tests in createToken.t.sol
each drop to a 2-line @notice + @dev docblock; the section header
collapses from a 12-line preamble to a single pointer comment.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Tests with arguments are fuzzed by default in Foundry; the suite
convention is test_{function}_{condition}_{case} regardless of
whether the body uses fuzz inputs.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Replaces the per-comparison `keccak256("AED")` form with a block of
named `bytes3 private constant` declarations at the top of the
library, and a comparison chain that reads `c == AED || c == AFN ||
...`. The canonical 155-entry list is now visible as data, easier
to scan against ISO 4217, and easier to audit for additions and
removals.

Honest tradeoff: per-call gas is ~2× higher than the keccak chain
(the Solidity optimizer was already constant-folding the keccak
literals, so the old version was paying ~1 keccak on the input
plus 155 PUSH32 EQ; the new version pays no keccak but ~155 PUSH3
EQ with additional control-flow overhead). Impact is mock-test
runtime only; the Rust precompile will hash to O(1) regardless.

A first-byte-dispatch optimization (`if (first == "U") return c ==
USD || ...`) would bring gas below the keccak version while keeping
the readability win; deferred until reviewer signs off on the
readability direction.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Wraps the bytes3 comparison chain in an outer `if (first == "X")`
dispatch on the input's first byte, so each call evaluates at most
one letter bucket instead of all 26. Worst-case ≈ 16 word-equality
comparisons (S bucket, 15 entries) plus a few first-byte branches,
vs ≈ 155 for a flat chain.

Per-call gas (micro-bench, 100 calls each):
  - keccak (original):  4.2k / call
  - bytes3 flat:        7.9k / call
  - this commit:        1.4k / call  (~3× faster than keccak)

Adds an @dev natspec on `isValidFiatCode` documenting the O(1)
amortized characteristic and the worst-case bucket size.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Sorts entries within each first-byte bucket so the highest-volume
codes short-circuit first. USD/EUR/JPY/GBP/CHF/CAD/AUD/SEK/NOK/NZD
(G10) lead their respective buckets, with CNY, INR, MXN, BRL, etc.
following close behind. Also reorders the outer letter-dispatch so
the buckets containing the G10 and top-FX currencies are checked
first (U/E/J/G/C/A/N/S/I/M/T/P/K/B/H/R/D/X/Z/V/L), keeping
single-entry buckets at the end.

Per-call gas (micro-bench, 100 calls each):
  - USD: ~370 gas  (was 1.4k; ~11× the original keccak chain's 4.2k)
  - EUR: ~400 gas
  - JPY: ~430 gas
  - ZWG (late): ~1.3k
  - ZZZ (miss): ~1.3k

Mean for real-world stablecoin traffic — overwhelmingly USD with
EUR a distant second — is effectively two comparisons (first-byte
+ first match), per the power-law observation that drove the sort.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The library is consumed only by the mock factory and test fuzzers,
so it doesn't belong under src/ — moving to test/lib/ alongside
the other mock-supporting code keeps the src/ surface focused on
the interfaces consumers actually import. The src/utils/ directory
is removed (it had no other entries).

Also appends a "Supported currencies" table to the docs page
listing all 155 allowlist codes alphabetically with currency name
and region/issuer. Useful as a self-contained reference so readers
don't have to map ISO 4217 codes to currencies themselves.

Updates the docs-file path link from src/utils/ to test/lib/ and
the natspec import paths in MockTokenFactory and createToken.t.sol.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Captures the five validation approaches surfaced during design (no
validation, format-only, full ISO 4217, narrow ISO 4217 fiat, off-
chain registry) with a one-line rationale for why each non-chosen
option was set aside. Marks the chosen approach inline so the
decision is visible without forcing readers to cross-reference the
solution section.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Bolds the option title, moves the description underneath with a
`<br>` separator, and splits the per-option rationale into
bulleted Pros and Cons columns. Easier to scan; trade-offs land on
both sides of the line rather than only as a single "why not"
sentence.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
… row

Adds manual `<br>` breaks within each Option-column description so
the column stops sprawling and the Pros/Cons columns get balanced
width. Removes the off-chain registry row — it's a non-option (no
on-chain validation defeats the whole purpose).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@ilikesymmetry ilikesymmetry merged commit 96d3b37 into main May 21, 2026
3 checks passed
@ilikesymmetry ilikesymmetry deleted the feat/iso4217-currency-validation branch May 21, 2026 20:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant